昨天我們使用 Angular 原生 Signal 與 NgRx SignalStore 實作購物車,透過 State 管理商品資料,再利用 Computed 計算商品數量與總金額。
不過,當購物車需要根據不同會員套用折扣時,可能會直接在 Store 裡面增加判斷:
const finalTotal = computed(() => {
const total = store.total();
if (memberType() === 'vip') {
return total * 0.9;
}
if (memberType() === 'business') {
return total * 0.85;
}
return total;
});
目前只有三種會員,這樣寫沒有什麼問題。但如果未來增加更多折扣規則,Store 就需要不斷修改。
今天我們會使用 Strategy Pattern(策略模式) 拆分折扣計算,再搭配 Angular Dependency Injection(DI)與 Signal,讓會員類型改變時,可以動態切換對應的折扣規則。
Strategy Pattern 是一種行為型設計模式,主要是將不同的演算法或業務規則封裝成獨立物件,讓使用端透過共同介面操作。
以購物車為例,假設有三種會員:
三種會員的計算方式不同,但都需要根據總金額計算折扣,因此可以先定義共同介面。
discount.strategy.ts
import { Injectable } from '@angular/core';
export interface DiscountStrategy {
calculate(total: number): number;
}
@Injectable({ providedIn: 'root' })
export class NormalDiscount implements DiscountStrategy {
calculate(total: number) {
return total;
}
}
@Injectable({ providedIn: 'root' })
export class VipDiscount implements DiscountStrategy {
calculate(total: number) {
return total * 0.9;
}
}
@Injectable({ providedIn: 'root' })
export class BusinessDiscount implements DiscountStrategy {
calculate(total: number) {
return total * 0.85;
}
}
每個 Class 都實作 DiscountStrategy,提供相同的 calculate() 方法,但使用不同的折扣規則。未來新增其他折扣時,只需要建立新的 Strategy,不必將所有判斷集中在 Store 裡。
這裡透過 @Injectable({ providedIn: 'root' }) 將三個 Strategy 註冊到 Angular DI。它們通常會在第一次被注入時才建立實體,之後由 Root Injector 共用,而不是 Application 啟動時就全部建立。
下圖整理三個 Strategy 與共同介面的關係,無論使用哪種折扣規則,都能透過相同的 calculate() 方法計算。

將折扣規則拆開後,還需要根據目前的會員類型,選擇要使用哪一種 Strategy。
這次建立 DiscountService,透過 Signal 記錄會員類型,並利用 Angular DI 取得三個折扣策略。
discount.service.ts
import { Injectable, inject, signal } from '@angular/core';
import {
NormalDiscount,
VipDiscount,
BusinessDiscount
} from './discount.strategy';
import type { DiscountStrategy } from './discount.strategy';
export type MemberType = 'normal' | 'vip' | 'business';
@Injectable({ providedIn: 'root' })
export class DiscountService implements DiscountStrategy {
readonly memberType = signal<MemberType>('normal');
private readonly strategies = {
normal: inject(NormalDiscount),
vip: inject(VipDiscount),
business: inject(BusinessDiscount)
};
calculate(total: number) {
return this.strategies[
this.memberType()
].calculate(total);
}
change(type: MemberType) {
this.memberType.set(type);
}
}
DiscountService 本身也實作了 DiscountStrategy,但不直接計算折扣,而是根據 memberType() 選擇對應的 Strategy。
這裡透過 inject() 取得三個 Strategy,因此當 DiscountService 第一次建立時,就會一併取得這些實體。之後呼叫 change() 切換會員類型時,只需要改用對應的 Strategy,不需要重新建立。
例如目前是 VIP 會員,就使用 VipDiscount;當呼叫 change('business') 後,便改由 BusinessDiscount 計算。
有了 DiscountService 後,我們希望 SignalStore 只需要使用 calculate(),不必直接依賴 DiscountService 這個 Class。
由於 TypeScript Interface 在編譯後不會保留,無法直接作為 Angular DI 的 Token,因此需要建立 InjectionToken。
discount.token.ts
import { InjectionToken } from '@angular/core';
import type { DiscountStrategy } from './discount.strategy';
export const DISCOUNT_STRATEGY =
new InjectionToken<DiscountStrategy>('DISCOUNT_STRATEGY');
DISCOUNT_STRATEGY 是注入時使用的 Token,泛型則指定取得的物件需要符合 DiscountStrategy 介面。
接著在 app.config.ts 註冊:
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
import { DISCOUNT_STRATEGY } from './cart/discount.token';
import { DiscountService } from './cart/discount.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
{
provide: DISCOUNT_STRATEGY,
useExisting: DiscountService
}
]
};
這裡使用 useExisting,讓 DISCOUNT_STRATEGY 成為 DiscountService 的另一個注入名稱。由於 DiscountService 已經透過 providedIn: 'root' 註冊,因此不需要再加入 providers。
useExisting 與 useClass 的差別在於是否共用實體:
| Provider | 說明 |
|---|---|
useClass |
由 DI 為該 Token 建立指定 Class 的實體 |
useExisting |
使用另一個 Token 對應的同一個實體 |
如果改成 useClass: DiscountService,Angular 就會為 DISCOUNT_STRATEGY 建立另一個 DiscountService 實體,與原本透過 Root 註冊的實體分開管理。
這次使用 useExisting,是因為 Component 需要切換會員類型,而 SignalStore 需要根據相同的會員狀態計算折扣,因此兩者必須取得同一個 DiscountService 實體。

延續昨天的 NgRxCartStore,保留原本的購物車功能,另外增加折扣後的 finalTotal。
ngrx-cart.store.ts
import { computed, inject } from '@angular/core';
import {
signalStore,
withState,
withComputed,
withMethods,
patchState
} from '@ngrx/signals';
import type { CartItem, Product } from './cart.types';
import { DISCOUNT_STRATEGY } from './discount.token';
export const NgRxCartStore = signalStore(
{ providedIn: 'root' },
withState({
items: [] as CartItem[]
}),
withComputed(({ items }) => {
const discount = inject(DISCOUNT_STRATEGY);
const count = computed(() =>
items().reduce(
(sum, item) => sum + item.quantity,
0
)
);
const total = computed(() =>
items().reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
);
const finalTotal = computed(() =>
discount.calculate(total())
);
return { count, total, finalTotal };
}),
withMethods(store => ({
add(product: Product) {
patchState(store, state => {
const existing = state.items.find(
item => item.id === product.id
);
const items = existing
? state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
: [...state.items, { ...product, quantity: 1 }];
return { items };
});
},
remove(id: number) {
patchState(store, state => ({
items: state.items.filter(item => item.id !== id)
}));
},
clear() {
patchState(store, { items: [] });
}
}))
);
這次主要新增:
const discount = inject(DISCOUNT_STRATEGY);
const finalTotal = computed(() =>
discount.calculate(total())
);
inject(DISCOUNT_STRATEGY) 會透過 Angular DI 取得前面註冊的 DiscountService 實體,再由 calculate() 計算折扣後的金額。
這裡也可以直接使用 inject(DiscountService),但透過 DISCOUNT_STRATEGY,Store 只需要知道 calculate(),不必依賴 DiscountService 的其他功能。未來如果更換成其他符合介面的折扣服務,也不需要修改 Store。
最後在 Component 加入會員選單,讓使用者可以切換折扣。
cart.ts
import { Component, inject } from '@angular/core';
import { NgRxCartStore } from './ngrx-cart.store';
import { DiscountService } from './discount.service';
import type { MemberType } from './discount.service';
@Component({
selector: 'app-root',
template: `
<main style="padding: 24px; max-width: 600px;">
<h1>Strategy Pattern 購物車</h1>
<h3>商品列表</h3>
<button (click)="store.add({
id: 1,
name: '鍵盤',
price: 1200
})">
加入鍵盤 $1200
</button>
<button (click)="store.add({
id: 2,
name: '滑鼠',
price: 800
})">
加入滑鼠 $800
</button>
<hr />
<h3>會員類型</h3>
<select
[value]="discount.memberType()"
(change)="changeMember($event)"
>
<option value="normal">一般會員</option>
<option value="vip">VIP 會員</option>
<option value="business">企業會員</option>
</select>
<hr />
<h3>購物車</h3>
@for (item of store.items(); track item.id) {
<p>
{{ item.name }} × {{ item.quantity }}
= {{ item.price * item.quantity }} 元
<button (click)="store.remove(item.id)">
移除
</button>
</p>
} @empty {
<p>購物車目前沒有商品</p>
}
<hr />
<p>商品數量:{{ store.count() }}</p>
<p>原價:{{ store.total() }} 元</p>
<h2>折扣後:{{ store.finalTotal() }} 元</h2>
<button (click)="store.clear()">
清空購物車
</button>
</main>
`
})
export class CartComponent {
readonly store = inject(NgRxCartStore);
readonly discount = inject(DiscountService);
changeMember(event: Event) {
const select = event.target as HTMLSelectElement;
this.discount.change(select.value as MemberType);
}
}
Component 直接注入 DiscountService,因為需要呼叫 change() 切換會員類型;SignalStore 則透過 DISCOUNT_STRATEGY 使用同一個實體來計算折扣。
假設購物車有一個 1,200 元的鍵盤,以及一個 800 元的滑鼠:
| 會員類型 | 折扣 | 結帳金額 |
|---|---|---|
| 一般會員 | 原價 | 2,000 元 |
| VIP 會員 | 9 折 | 1,800 元 |
| 企業會員 | 85 折 | 1,700 元 |
切換會員類型後,購物車商品與原價都不會改變,只有折扣金額會跟著更新。

Angular SignalStore 與 Strategy Pattern,透過 DI 動態切換業務邏輯 - StackBlitz
回頭看 SignalStore 裡面的 finalTotal:
const finalTotal = computed(() =>
discount.calculate(total())
);
雖然這裡沒有直接讀取 memberType(),但 DiscountService 的 calculate() 內部會使用這個 Signal:
calculate(total: number) {
return this.strategies[
this.memberType()
].calculate(total);
}
Angular 的 computed() 會追蹤計算過程中讀取的 Signal,即使透過其他方法間接讀取,也一樣會建立依賴關係。
因此,finalTotal 同時依賴購物車的 total() 與會員類型的 memberType()。當其中一個改變時,就會在下次讀取時重新計算。
需要注意的是,Runtime 切換的是 DiscountService 內部使用的 Strategy,而不是 Angular DI Provider 本身。DI 負責提供實體,Signal 則讓折扣計算能隨著會員類型改變。
這次為了示範,讓使用者可以直接切換會員類型。實際系統中的會員等級通常由登入資訊或後端資料決定,最終結帳金額也應由後端驗證。
今天透過 Strategy Pattern,將不同會員的折扣規則拆成獨立 Class,讓 SignalStore 不需要處理各種折扣判斷,只需要呼叫共同的 calculate() 方法。
Angular DI 負責建立與管理 Strategy,DiscountService 則透過 inject() 取得各個折扣實體。搭配 InjectionToken 與 useExisting,即使 SignalStore 與 Component 注入不同的 Token,也能共用同一個 Service。
當會員類型改變時,DiscountService 會選擇對應的 Strategy,而 SignalStore 的 Computed 也會依照最新狀態重新計算金額,不需要重新建立 Store 或替換 DI Provider。
這個範例將折扣規則與購物車狀態分開管理。未來需要增加新的會員折扣時,可以擴充 Strategy,讓原本的 Store 不必跟著調整。
useExisting 共用實體,useClass 另建實體。InjectionToken。computed() 追蹤計算中實際讀取的 Signal。withComputed() 在 Injection Context 執行,可使用 inject()。